Skip to main content

Đồng bộ hóa và Lock trong Java

Trong Java, việc xử lý đa luồng (multithreading) đòi hỏi phải có các cơ chế đồng bộ hóa (synchronization) nhằm đảm bảo rằng các luồng truy cập vào tài nguyên chia sẻ không gây ra xung đột hoặc lỗi không mong muốn. Hai trong số các cơ chế đồng bộ phổ biến nhất là từ khóa synchronized và các lớp Lock trong package java.util.concurrent.locks. Bài viết dưới đây sẽ giới thiệu lý thuyết cơ bản, cách thức hoạt động và những ví dụ cụ thể cho từng cơ chế này.


1. Đồng Bộ Hóa (Synchronization) trong Java

1.1. Lý do cần đồng bộ hóa

Khi nhiều luồng cùng truy cập vào một đối tượng hoặc tài nguyên chia sẻ (như biến, danh sách, file, ...), nếu không có cơ chế đồng bộ hóa, có thể xảy ra các vấn đề như:

  • Race condition (điều kiện đua nhau): Các luồng thay đổi giá trị của tài nguyên chia sẻ theo thứ tự không xác định.
  • Data inconsistency (mâu thuẫn dữ liệu): Kết quả không mong muốn khi dữ liệu bị thay đổi trong quá trình tính toán.

Đồng bộ hóa đảm bảo rằng chỉ một luồng được truy cập vào đoạn mã quan trọng tại một thời điểm, từ đó bảo vệ dữ liệu và trạng thái của đối tượng.


2. Từ Khóa synchronized

2.1. Lý thuyết về synchronized

  • Mục đích:
    Từ khóa synchronized cho phép chỉ một luồng được thực thi một đoạn mã cụ thể (hoặc phương thức) trong một thời điểm, bằng cách sử dụng cơ chế "khóa" (lock) trên đối tượng hoặc lớp.

  • Cách hoạt động:
    Khi một phương thức hoặc khối mã được đánh dấu bằng synchronized, JVM sẽ tạo ra một lock (monitor) cho đối tượng (hoặc lớp nếu là static). Khi một luồng đã chiếm quyền sử dụng lock này, các luồng khác phải đợi cho đến khi lock được giải phóng.

  • Các loại synchronized:

    • Synchronized Instance Methods: Đồng bộ hóa dựa trên đối tượng cụ thể.
    • Synchronized Static Methods: Đồng bộ hóa dựa trên lớp (class-level lock).
    • Synchronized Blocks: Đồng bộ hóa một khối mã cụ thể, cho phép linh hoạt trong việc chỉ định đối tượng dùng làm lock.

2.2. Ví dụ sử dụng synchronized

a. Synchronized Instance Method

public class Counter {
private int count = 0;

// Phương thức đồng bộ hóa, đảm bảo chỉ có một luồng truy cập tại một thời điểm
public synchronized void increment() {
count++;
}

public int getCount() {
return count;
}
}

Giải thích:
Phương thức increment() được đánh dấu là synchronized, do đó nếu nhiều luồng cùng gọi phương thức này trên cùng một đối tượng Counter, chỉ có một luồng được phép thực hiện phương thức tại một thời điểm, tránh việc cập nhật sai số của biến count.

b. Synchronized Static Method

public class StaticCounter {
private static int count = 0;

// Đồng bộ hóa theo lớp, lock sẽ được sử dụng trên đối tượng Class của StaticCounter
public static synchronized void increment() {
count++;
}

public static int getCount() {
return count;
}
}

Giải thích:
Ở đây, lock được áp dụng trên đối tượng StaticCounter.class. Điều này đảm bảo rằng, ngay cả khi có nhiều đối tượng của StaticCounter, các luồng gọi phương thức increment() đều sẽ được đồng bộ hóa.

c. Synchronized Block

public class SynchronizedBlockExample {
private final Object lock = new Object();
private int count = 0;

public void increment() {
// Chỉ đồng bộ khối mã cần thiết
synchronized (lock) {
count++;
}
}

public int getCount() {
return count;
}
}

Giải thích:
Sử dụng synchronized block cho phép chỉ đồng bộ một phần của phương thức, giúp tối ưu hiệu năng nếu chỉ một phần mã cần được bảo vệ. Ở đây, đối tượng lock được sử dụng làm khóa, cho phép dễ dàng kiểm soát và có thể thay đổi linh hoạt nếu cần thiết.


3. Locks trong Java

3.1. Lý thuyết về Lock

  • Giới thiệu:
    Các lớp Lock, như ReentrantLock, cung cấp khả năng đồng bộ hóa tương tự như từ khóa synchronized nhưng với nhiều tính năng linh hoạt hơn:

    • Có thể thử khóa không bị chặn (non-blocking).
    • Hỗ trợ cơ chế chờ (condition variables).
    • Khả năng công bằng (fairness) khi cấp phát lock.
  • Ưu điểm so với synchronized:

    • Kiểm soát chính xác hơn: Cho phép kiểm soát thời gian chờ, thử khóa, hủy bỏ chờ,...
    • Độ linh hoạt cao: Cho phép tạo nhiều điều kiện chờ khác nhau trên cùng một lock thông qua các đối tượng Condition.
    • Khả năng công bằng: Cho phép tạo lock công bằng giúp cấp phát quyền truy cập theo thứ tự.
  • Nhược điểm:

    • Cần phải tự quản lý việc giải phóng lock, nếu không có lỗi (ví dụ: quên gọi unlock()), có thể dẫn đến deadlock.
    • Cú pháp phức tạp hơn so với synchronized.

3.2. Ví dụ sử dụng ReentrantLock

a. Ví dụ cơ bản

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {
private int count = 0;
private final Lock lock = new ReentrantLock();

public void increment() {
lock.lock(); // Chiếm quyền khóa
try {
count++;
} finally {
lock.unlock(); // Luôn đảm bảo giải phóng khóa
}
}

public int getCount() {
return count;
}

public static void main(String[] args) {
LockExample counter = new LockExample();

// Tạo 1000 luồng, mỗi luồng tăng giá trị count 100 lần
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
counter.increment();
}
};

Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
threads[i].start();
}

// Chờ cho tất cả các luồng kết thúc
for (Thread thread : threads) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

System.out.println("Final count: " + counter.getCount());
}
}

Giải thích:
Trong ví dụ trên, ReentrantLock được sử dụng để đảm bảo rằng chỉ một luồng được phép thực hiện đoạn mã trong phương thức increment() tại một thời điểm. Cấu trúc try-finally đảm bảo rằng dù có lỗi xảy ra, lock cũng sẽ được giải phóng.

b. Sử dụng Condition với Lock

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ProducerConsumer {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final int[] buffer = new int[5];
private int count = 0;
private int putIndex = 0;
private int takeIndex = 0;

// Producer: thêm phần tử vào buffer
public void put(int value) throws InterruptedException {
lock.lock();
try {
while (count == buffer.length) {
// Nếu buffer đầy, chờ cho đến khi có không gian
notFull.await();
}
buffer[putIndex] = value;
putIndex = (putIndex + 1) % buffer.length;
count++;
notEmpty.signal(); // Thông báo cho consumer
} finally {
lock.unlock();
}
}

// Consumer: lấy phần tử từ buffer
public int take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
// Nếu buffer rỗng, chờ cho đến khi có phần tử
notEmpty.await();
}
int value = buffer[takeIndex];
takeIndex = (takeIndex + 1) % buffer.length;
count--;
notFull.signal(); // Thông báo cho producer
return value;
} finally {
lock.unlock();
}
}
}

Giải thích:
Trong ví dụ về mô hình Producer-Consumer, chúng ta sử dụng ReentrantLock kết hợp với các đối tượng Condition để kiểm soát việc thêm và lấy phần tử trong một buffer giới hạn. Khi buffer đầy, producer sẽ đợi (await) cho đến khi consumer lấy đi phần tử và thông báo (signal) rằng có chỗ trống. Tương tự, khi buffer rỗng, consumer sẽ chờ cho đến khi producer thêm vào.


4. So Sánh synchronized và Lock

Tiêu chísynchronizedLock (ReentrantLock)
Cú phápĐơn giản, tích hợp sẵn trong JavaPhức tạp hơn, cần quản lý thủ công unlock
Khả năng chờKhông hỗ trợ timeoutHỗ trợ timeout với phương thức tryLock
Độ linh hoạtÍt tùy biến, không có condition variablesHỗ trợ nhiều condition và có thể kiểm soát công bằng
Hiệu năngĐủ cho hầu hết các trường hợpCó thể tối ưu trong các tình huống phức tạp, đặc biệt với lock công bằng và timeout

5. Tổng Kết

  • synchronized là cơ chế đồng bộ đơn giản, dễ sử dụng và tích hợp sẵn trong Java. Nó phù hợp với các trường hợp đồng bộ hóa cơ bản, khi chỉ cần bảo vệ một khối mã hoặc phương thức khỏi việc truy cập đồng thời.
  • Locks (như ReentrantLock) cung cấp nhiều tính năng mạnh mẽ và linh hoạt hơn, cho phép:
    • Thử khóa với timeout.
    • Sử dụng nhiều điều kiện chờ (Condition).
    • Quản lý công bằng trong cấp phát quyền.
  • Việc lựa chọn giữa synchronized và Lock phụ thuộc vào yêu cầu cụ thể của ứng dụng cũng như mức độ phức tạp của việc quản lý luồng.

Nhờ vào những cơ chế đồng bộ này, Java cho phép các ứng dụng đa luồng được xây dựng một cách an toàn, hiệu quả và tránh được những lỗi thường gặp như race condition hay deadlock. Việc hiểu và vận dụng đúng synchronized và Lock sẽ giúp bạn xây dựng được các ứng dụng ổn định, tin cậy trong môi trường đa luồng.